| 1 | import type { Metadata } from "next"; |
| 2 | import Link from "next/link"; |
| 3 | import { notFound } from "next/navigation"; |
| 4 | import { Card, SectionHeader } from "@/components/ui"; |
| 5 | import { DataTable, type Column, type Row } from "@/components/data-table"; |
| 6 | import { SourceLink } from "@/components/source-link"; |
| 7 | import { PartyTag } from "@/components/party-tag"; |
| 8 | import { PositionTag } from "@/components/position-tag"; |
| 9 | import { MemberAvatar } from "@/components/member-avatar"; |
| 10 | import { StatusPill, classifyStatus, type StatusVariant } from "@/components/status-pill"; |
| 11 | import { |
| 12 | getMember, |
| 13 | type MemberVote, |
| 14 | type MemberAmendment, |
| 15 | } from "@/lib/feed"; |
| 16 | import { MemberVoteYear } from "@/components/member-vote-year"; |
| 17 | import { formatDate } from "@/lib/format"; |
| 18 | import { CiteThis } from "@/components/cite-this"; |
| 19 | |
| 20 | export const revalidate = 300; |
| 21 | |
| 22 | |
| 23 | const STATUS_SHORT: Record<StatusVariant, string> = { |
| 24 | passed: "Passed", |
| 25 | failed: "Failed", |
| 26 | held: "In committee", |
| 27 | pending: "Introduced", |
| 28 | neutral: "Introduced", |
| 29 | }; |
| 30 | |
| 31 | export function generateMetadata({ |
| 32 | params, |
| 33 | }: { |
| 34 | params: { id: string }; |
| 35 | }): Metadata { |
| 36 | const member = getMember(params.id); |
| 37 | if (!member) return { title: "Member not found" }; |
| 38 | return { |
| 39 | title: `${member.name} (${member.partyCode}-${member.state})`, |
| 40 | description: `Recorded votes, sponsored bills, and amendments for ${member.name}.`, |
| 41 | }; |
| 42 | } |
| 43 | |
| 44 | function formatDistrict(chamber: string, district: number | null): string { |
| 45 | if (chamber === "senate") return ""; |
| 46 | if (district === null || district <= 0) return "At-Large"; |
| 47 | return `District ${district}`; |
| 48 | } |
| 49 | |
| 50 | export default function MemberDetailPage({ |
| 51 | params, |
| 52 | }: { |
| 53 | params: { id: string }; |
| 54 | }) { |
| 55 | const member = getMember(params.id); |
| 56 | if (!member) notFound(); |
| 57 | |
| 58 | return ( |
| 59 | <div |
| 60 | className="mx-auto w-full max-w-[860px] overflow-x-hidden px-6 sm:px-8" |
| 61 | style={{ |
| 62 | paddingTop: "clamp(32px, 6vw, 56px)", |
| 63 | paddingBottom: "clamp(48px, 8vw, 80px)", |
| 64 | }} |
| 65 | > |
| 66 | <Link |
| 67 | href="/members" |
| 68 | className="focus-ring tap no-underline hover:underline" |
| 69 | style={{ |
| 70 | display: "inline-flex", |
| 71 | alignItems: "center", |
| 72 | gap: "8px", |
| 73 | fontFamily: "var(--font-mono), ui-monospace, monospace", |
| 74 | fontSize: "13px", |
| 75 | letterSpacing: "0.08em", |
| 76 | color: "var(--dk-signal)", |
| 77 | marginBottom: "32px", |
| 78 | }} |
| 79 | > |
| 80 | ← Representatives |
| 81 | </Link> |
| 82 | |
| 83 | { |
| 84 | } |
| 85 | <header |
| 86 | style={{ |
| 87 | borderBottom: "1px solid var(--dk-line)", |
| 88 | paddingBottom: "clamp(28px, 5vw, 48px)", |
| 89 | marginBottom: "clamp(28px, 5vw, 48px)", |
| 90 | }} |
| 91 | > |
| 92 | {} |
| 93 | <p |
| 94 | className="label" |
| 95 | style={{ |
| 96 | color: "var(--dk-signal)", |
| 97 | marginBottom: "20px", |
| 98 | display: "flex", |
| 99 | alignItems: "center", |
| 100 | gap: "8px", |
| 101 | fontSize: "12px", |
| 102 | letterSpacing: "0.14em", |
| 103 | }} |
| 104 | > |
| 105 | <span |
| 106 | aria-hidden="true" |
| 107 | style={{ |
| 108 | width: "5px", |
| 109 | height: "5px", |
| 110 | borderRadius: "50%", |
| 111 | background: "var(--dk-signal)", |
| 112 | display: "inline-block", |
| 113 | flexShrink: 0, |
| 114 | }} |
| 115 | /> |
| 116 | {member.chamber === "senate" ? "U.S. Senate" : "U.S. House"} · {member.state} |
| 117 | {member.chamber !== "senate" && formatDistrict(member.chamber, member.district) |
| 118 | ? ` · ${formatDistrict(member.chamber, member.district)}` |
| 119 | : ""} |
| 120 | </p> |
| 121 | <div className="flex flex-col gap-5 sm:flex-row sm:items-start"> |
| 122 | <MemberAvatar |
| 123 | name={member.name} |
| 124 | photoUrl={member.photoUrl} |
| 125 | size="lg" |
| 126 | className="shrink-0" |
| 127 | /> |
| 128 | <div className="min-w-0 flex-1"> |
| 129 | <h1 |
| 130 | className="font-display text-paper" |
| 131 | style={{ |
| 132 | fontSize: "clamp(2.2rem, 7vw, 3.75rem)", |
| 133 | fontWeight: 900, |
| 134 | lineHeight: 1.0, |
| 135 | letterSpacing: "-0.01em", |
| 136 | wordSpacing: "0.06em", |
| 137 | textTransform: "uppercase", |
| 138 | marginBottom: "20px", |
| 139 | }} |
| 140 | > |
| 141 | {member.name} |
| 142 | </h1> |
| 143 | <div className="flex flex-wrap items-center gap-3"> |
| 144 | <PartyTag party={member.partyCode} /> |
| 145 | <span className="font-mono text-[13px] text-ash"> |
| 146 | {member.party || "Party not on record"} |
| 147 | </span> |
| 148 | <span className="label text-ash-2" aria-hidden="true">·</span> |
| 149 | <span className="font-mono text-[13px] text-ash-2"> |
| 150 | {member.voteCount} recorded position{member.voteCount === 1 ? "" : "s"} on file |
| 151 | </span> |
| 152 | </div> |
| 153 | </div> |
| 154 | </div> |
| 155 | {member.photoUrl ? ( |
| 156 | <p className="label mt-3 text-ash-2"> |
| 157 | Headshot from the official congressional image record. Official biography below. |
| 158 | </p> |
| 159 | ) : ( |
| 160 | <p className="label mt-3 text-ash-2"> |
| 161 | No official headshot on file for this member. |
| 162 | </p> |
| 163 | )} |
| 164 | </header> |
| 165 | |
| 166 | {} |
| 167 | <div className="grid gap-4 sm:grid-cols-3"> |
| 168 | <Card className="p-5"> |
| 169 | <p className="label mb-2">Party</p> |
| 170 | <PartyTag party={member.partyCode} /> |
| 171 | <p className="label mt-2 text-ash-2">{member.party || "-"}</p> |
| 172 | </Card> |
| 173 | |
| 174 | <Card className="p-5"> |
| 175 | <p className="label mb-2">State / district</p> |
| 176 | <p className="font-mono text-[13px] text-paper">{member.state}</p> |
| 177 | <p className="label mt-2 text-ash-2"> |
| 178 | {member.chamber === "senate" |
| 179 | ? "Statewide" |
| 180 | : formatDistrict(member.chamber, member.district) || "Statewide"} |
| 181 | </p> |
| 182 | </Card> |
| 183 | |
| 184 | <Card className="p-5"> |
| 185 | <p className="label mb-2">Official record</p> |
| 186 | <SourceLink url={member.sourceUrl} label="Bioguide" /> |
| 187 | <p className="label mt-2 text-ash-2">{member.bioguide}</p> |
| 188 | </Card> |
| 189 | </div> |
| 190 | |
| 191 | <div className="mt-10 grid gap-8 lg:grid-cols-[1.3fr_1fr]"> |
| 192 | { |
| 193 | } |
| 194 | <div className="min-w-0"> |
| 195 | <SectionHeader |
| 196 | eyebrow={`Most recent · ${Math.min(member.votes.length, 12)} of ${member.voteCount.toLocaleString()}`} |
| 197 | title="How they voted" |
| 198 | /> |
| 199 | {member.votes.length === 0 ? ( |
| 200 | <Card className="p-5"> |
| 201 | <p className="font-mono text-[13px] text-ash-2"> |
| 202 | No recorded vote positions are linked to this member. |
| 203 | </p> |
| 204 | </Card> |
| 205 | ) : ( |
| 206 | <RecentVotes votes={member.votes.slice(0, 12)} /> |
| 207 | )} |
| 208 | </div> |
| 209 | |
| 210 | {} |
| 211 | <div className="min-w-0"> |
| 212 | <SectionHeader |
| 213 | eyebrow={`Sponsored · ${member.sponsorships.length}`} |
| 214 | title="Bills introduced" |
| 215 | /> |
| 216 | {member.sponsorships.length === 0 ? ( |
| 217 | <Card className="p-5"> |
| 218 | <p className="font-mono text-[13px] text-ash-2"> |
| 219 | No sponsored bills are linked to this member in the current record. |
| 220 | </p> |
| 221 | </Card> |
| 222 | ) : ( |
| 223 | <Card className="divide-y divide-[color:var(--dk-line)]"> |
| 224 | {member.sponsorships.slice(0, 12).map((b) => ( |
| 225 | <div key={b.billId} className="p-4"> |
| 226 | <Link |
| 227 | href={b.billHref} |
| 228 | className="focus-ring font-mono text-[13px] text-paper underline underline-offset-4 hover:text-paper" |
| 229 | > |
| 230 | {b.number} |
| 231 | </Link> |
| 232 | <p className="mt-1 font-mono text-[12px] text-ash"> |
| 233 | {b.title} |
| 234 | </p> |
| 235 | <div className="mt-2 flex items-center gap-2"> |
| 236 | <StatusPill variant={classifyStatus(b.status)}> |
| 237 | {STATUS_SHORT[classifyStatus(b.status)]} |
| 238 | </StatusPill> |
| 239 | <span className="label text-ash-2">{formatDate(b.updatedAt)}</span> |
| 240 | </div> |
| 241 | </div> |
| 242 | ))} |
| 243 | </Card> |
| 244 | )} |
| 245 | </div> |
| 246 | </div> |
| 247 | |
| 248 | {} |
| 249 | <div className="mt-10"> |
| 250 | <SectionHeader |
| 251 | eyebrow={`By year · ${member.votesByYear.length} year${member.votesByYear.length === 1 ? "" : "s"}`} |
| 252 | title="Vote record, year by year" |
| 253 | description="Every recorded roll-call position this member has taken, grouped by year. Open a year to read each vote and how they voted. Newest year first." |
| 254 | /> |
| 255 | {member.votesByYear.length === 0 ? ( |
| 256 | <Card className="p-5"> |
| 257 | <p className="font-mono text-[13px] text-ash-2"> |
| 258 | No recorded vote positions are linked to this member. |
| 259 | </p> |
| 260 | </Card> |
| 261 | ) : ( |
| 262 | <> |
| 263 | {} |
| 264 | <ul className="mb-5 flex flex-wrap gap-2"> |
| 265 | {member.votesByYear.map((yg) => ( |
| 266 | <li |
| 267 | key={yg.year ?? "undated"} |
| 268 | className="label inline-flex min-h-[44px] items-center gap-2 rounded-[3px] border border-line px-2.5 text-ash" |
| 269 | > |
| 270 | <span className="text-paper">{yg.year ?? "Undated"}</span> |
| 271 | <span className="text-ash-2">{yg.count}</span> |
| 272 | </li> |
| 273 | ))} |
| 274 | </ul> |
| 275 | |
| 276 | { |
| 277 | } |
| 278 | <div className="space-y-3"> |
| 279 | {member.votesByYear.map((yg, i) => ( |
| 280 | <MemberVoteYear |
| 281 | key={yg.year ?? "undated"} |
| 282 | bioguide={member.bioguide} |
| 283 | year={yg.year} |
| 284 | count={yg.count} |
| 285 | defaultOpen={i === 0} |
| 286 | /> |
| 287 | ))} |
| 288 | </div> |
| 289 | </> |
| 290 | )} |
| 291 | </div> |
| 292 | |
| 293 | {} |
| 294 | {member.amendments.length > 0 ? ( |
| 295 | <div className="mt-10"> |
| 296 | <SectionHeader |
| 297 | eyebrow={`Amendments · ${member.amendments.length}`} |
| 298 | title="Amendments offered" |
| 299 | /> |
| 300 | <MemberAmendmentsTable amendments={member.amendments} /> |
| 301 | </div> |
| 302 | ) : null} |
| 303 | |
| 304 | <div className="mt-12 border-t border-line pt-6"> |
| 305 | <p className="label text-ash-2"> |
| 306 | Vote positions and sponsorships from clerk.house.gov, senate.gov, and congress.gov. No comment or reaction surface appears here, by policy. |
| 307 | </p> |
| 308 | </div> |
| 309 | |
| 310 | {} |
| 311 | <div className="mt-8"> |
| 312 | <p className="label mb-4 text-ash">Cite this record</p> |
| 313 | <CiteThis |
| 314 | kind="member" |
| 315 | title={`${member.name} (${member.partyCode}-${member.state}) - Congressional Record`} |
| 316 | permalink={`https://usdocket.org${member.href}`} |
| 317 | sourceUrl={member.sourceUrl} |
| 318 | /> |
| 319 | </div> |
| 320 | </div> |
| 321 | ); |
| 322 | } |
| 323 | |
| 324 | |
| 325 | function RecentVotes({ votes }: { votes: MemberVote[] }) { |
| 326 | return ( |
| 327 | <Card className="divide-y divide-[color:var(--dk-line)]"> |
| 328 | {votes.map((v) => ( |
| 329 | <div key={v.voteId} className="flex items-start justify-between gap-3 p-4"> |
| 330 | <div className="min-w-0 flex-1"> |
| 331 | <Link |
| 332 | href={v.voteHref} |
| 333 | className="focus-ring block break-words font-mono text-[13px] text-paper underline underline-offset-4 hover:text-paper" |
| 334 | > |
| 335 | {v.question || v.result} |
| 336 | </Link> |
| 337 | <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 338 | <span className="label text-ash-2">{v.chamber}</span> |
| 339 | <span className="label text-ash-2" aria-hidden="true">·</span> |
| 340 | <span className="label tabular-nums text-ash-2">{formatDate(v.date)}</span> |
| 341 | <span className="label text-ash-2" aria-hidden="true">·</span> |
| 342 | <span className="label text-ash-2">{v.result}</span> |
| 343 | </p> |
| 344 | </div> |
| 345 | <PositionTag position={v.position} className="mt-0.5 shrink-0" /> |
| 346 | </div> |
| 347 | ))} |
| 348 | </Card> |
| 349 | ); |
| 350 | } |
| 351 | |
| 352 | function MemberAmendmentsTable({ |
| 353 | amendments, |
| 354 | }: { |
| 355 | amendments: MemberAmendment[]; |
| 356 | }) { |
| 357 | const columns: Column[] = [ |
| 358 | { key: "amdt", header: "Amendment", cardTitle: true }, |
| 359 | { key: "bill", header: "Bill" }, |
| 360 | { key: "status", header: "Status" }, |
| 361 | { key: "source", header: "Source", align: "right" }, |
| 362 | ]; |
| 363 | const rows: Row[] = amendments.map((a) => ({ |
| 364 | key: a.amendmentId, |
| 365 | cells: { |
| 366 | amdt: <span className="text-paper">{a.type} {a.number}</span>, |
| 367 | bill: |
| 368 | a.billHref ? ( |
| 369 | <Link |
| 370 | href={a.billHref} |
| 371 | className="focus-ring rounded-sm text-ash underline underline-offset-4 hover:text-paper" |
| 372 | > |
| 373 | Bill {a.billId} |
| 374 | </Link> |
| 375 | ) : ( |
| 376 | <span className="text-ash-2">-</span> |
| 377 | ), |
| 378 | status: <span className="uppercase text-ash-2">{a.status || "-"}</span>, |
| 379 | source: <SourceLink url={a.sourceUrl} />, |
| 380 | }, |
| 381 | })); |
| 382 | return ( |
| 383 | <DataTable |
| 384 | caption="Amendments offered by this member: amendment, bill, status, source." |
| 385 | cardLabel="Amendments" |
| 386 | columns={columns} |
| 387 | rows={rows} |
| 388 | /> |
| 389 | ); |
| 390 | } |